前面 22 天,每天都是一個 dayXX.html,HTML、CSS、JavaScript 全部擠在同一個檔案裡。今天開始做正式作品,第一件事就是把它拆開。
day23/
├── index.html ← 骨架:畫面上有哪些東西
├── style.css ← 外觀:長什麼樣子
└── game.js ← 邏輯:怎麼運作
這就是 Day 14 學到的「外觀歸 CSS,邏輯歸 JS」,只是現在連檔案都分開了。
連接方式:
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
...
<script src="game.js"></script>
</body>
兩個位置都有理由:
<link> 放 <head>:樣式要早點載入,不然畫面會先閃一下沒樣式的版本<script> 放 </body> 前:Day 10 的教訓 —— JS 要等元素都建立好才抓得到它們網頁伺服器有個預設規則:有人只輸入資料夾網址時,自動去找 index.html。
之後部署到 GitHub Pages,網址會是 https://帳號.github.io/ironman-js-battle/,不用在後面加檔名 —— 前提是檔案叫這個名字。這是我今天才知道的慣例由來。
maxHp 終於派上用場今天的新東西是血條,原理比我想的單純:
<div class="hp-bar"> <!-- 固定寬度的軌道 -->
<div class="hp-fill" id="enemyHpFill"></div> <!-- 實際血量 -->
</div>
const percent = (fighter.hp / fighter.maxHp) * 100;
fillEl.style.width = percent + "%";
就是改內層的寬度百分比。而算百分比需要「滿血是多少」—— 這就是 Day 14 補上的 maxHp 真正發揮作用的地方。當時它只是為了取代寫死的 18,現在它是血條的分母。
我也把兩邊的渲染合成同一個函式:
function renderFighter(fighter, nameEl, textEl, fillEl) { ... }
renderFighter(state.hero, heroName, heroHpText, heroHpFill);
renderFighter(state.enemy, enemyName, enemyHpText, enemyHpFill);
勇者和小怪的血條邏輯完全一樣,沒理由寫兩次 —— Day 8 學的「一個函式做一件事、不重複」在這裡第一次用在「同樣的事情做兩遍」上。
maxHp 改成負數原本的 QA 題目是「如果 hp 變成負數會怎樣」。但我想到另一個方向:如果 maxHp 本身就是負的呢?
輸入驗證我們做過了(Day 15),但那是防「使用者輸入」。maxHp 是程式裡的設定值,從來沒有人檢查過它。
enemy: { name: "小怪", hp: 60, maxHp: -60, atk: 15, def: 3 },
結果是一連串的問題,而且一個比一個嚴重。
percent = (43 / -60) * 100 = -71.67
fillEl.style.width = "-71.67%";
width: -71.67% 是無效的 CSS。 瀏覽器的處理方式是:直接忽略這行宣告,不報錯、不警告 —— 血條就停在上一個有效的寬度。
JS 開開心心地設定了,瀏覽器默默地不理它。又一個安靜失敗,而且這次是在 CSS 層級。 我們的安靜失敗清單又多一條,而且這條完全不在 JavaScript 的世界裡。

對照上圖:勇者那條(綠色)完全正常,小怪那條(紅色)血量都掉到 22 了還是滿的。同一段程式、同一個函式,只因為餵進去的 maxHp 不同,一邊正常一邊靜默失效。
if (percent <= 30) { 加上危險樣式 }
percent 是負數,而負數永遠小於 30 —— 所以危險判斷從第一秒就成立,而且再也不會改變。
這比「失效」更難察覺:如果它完全不作用,你會注意到;但它一直在作用,只是永遠都是同一個結果。
按下「再戰一次」之後:
state.enemy.hp = state.enemy.maxHp; // hp 被設成 -60
小怪的血變成 -60。然後第一次攻擊:
-60 - 17 = -77 → <= 0 → 強制歸零,isOver = true
第一擊就結束戰鬥,回合數是 1。 接著 saveRecordIfBetter(1) 把「1 回合」存進了 localStorage。
一筆永遠破不了的假紀錄,而且它活過重新整理、活過關閉瀏覽器。前面兩個現象只是畫面怪怪的,這一個是資料被污染了。
一個設定值寫錯,最後污染了持久化的資料。 這是今天最值得記下來的一條路徑。
我原本在渲染函式裡寫了這個:
const hp = Math.max(0, fighter.hp); // 防止顯示負數
看起來有處理負數了。但在主控台裡,state.enemy.hp 還是負的。
因為那行只保護了「顯示」,沒有保護「資料」。

這張截圖把兩層的落差拍得很清楚:畫面上小怪的血是 0,主控台裡的 enemy.hp 是 -60。而且左下角那行「最少回合紀錄:1 回合」還在 —— 那筆假紀錄已經被寫進去了。
| 層 | 狀況 |
|---|---|
| 畫面 | 顯示 0,看起來正常 |
| 資料 | 實際是 -60 |
| 依賴資料的東西 | 判斷、紀錄、之後的存檔 —— 全部拿到錯的值 |
顯示層的清理不等於資料層的正確。 畫面看起來沒事,不代表資料沒事 —— 而所有後續的邏輯讀的都是資料,不是畫面。
這個分層的觀念我以前在測試時隱約有感覺(「畫面對不代表後端對」),今天第一次在自己寫的程式裡看到它的樣子。
問題不在渲染函式,而在於沒有人檢查 maxHp 是否合理。所以修的地方應該是資料進入系統的那一刻:
function createFighter(name, maxHp, atk, def) {
if (maxHp <= 0) {
throw new Error(`${name} 的 maxHp 必須大於 0,收到 ${maxHp}`);
}
return { name, hp: maxHp, maxHp, atk, def };
}
throw new Error(...) 是主動製造一個大聲失敗 —— 與其讓錯誤的設定悄悄流進整個系統,不如一開始就吵起來。
這正好呼應 Day 21 的結論:安靜失敗最難查,所以有時候你要刻意把它變大聲。 以前我以為錯誤訊息是「壞事發生的證明」,現在覺得它更像是「有人幫你擋住了」。
老實說我原本擔心:拆成三個檔案,會不會反而要開更多分頁、找東西更麻煩?
實際用下來是比較好找,關鍵在於編輯器可以左右分割顯示。改血條樣式時,左邊開 CSS、右邊開 JS,兩邊對照著看。以前擠在一個檔案裡,同樣的事得上下捲來捲去,而且很容易捲過頭。
檔案變多不等於更亂,重點是每個檔案的職責夠明確,你知道要去哪裡找。
今天搞懂的事:
index.html / style.css / game.js,職責分離index.html 是因為伺服器會自動找它(部署時網址才乾淨)maxHp
<= 判斷永遠成立,這比失效更難發現throw 擋下 —— 主動把安靜失敗變大聲明天開始接戰鬥:讓小怪終於能還手。